Skip to content

Fix update attempt refreshing pill without actually updating#2168

Merged
lawrencecchen merged 3 commits intomainfrom
issue-2166-update-pill-no-update
Mar 25, 2026
Merged

Fix update attempt refreshing pill without actually updating#2168
lawrencecchen merged 3 commits intomainfrom
issue-2166-update-pill-no-update

Conversation

@lawrencecchen
Copy link
Copy Markdown
Contributor

@lawrencecchen lawrencecchen commented Mar 25, 2026

Summary

  • Add !state.isIdle to the attemptUpdate() subscriber teardown guard so monitoring only stops on terminal failures (.notFound, .error), not on transient .idle during check startup

The subscriber watched viewModel.$state to auto-confirm .updateAvailable. But it also tore down on any non-installable state after observing progress, including .idle. During check startup (retry loop waiting for canCheckForUpdates, background probe finishing), state can transiently return to .idle before Sparkle's interactive check begins. The subscriber interpreted this as a completed check and stopped, so the auto-confirm never fired.

Testing

  • Timing-dependent race, not reliably reproducible in automated tests. Validated by code review.

Related

Summary by CodeRabbit

  • Bug Fixes
    • Improved update monitoring stability to prevent premature termination during transient state transitions, ensuring more reliable update handling.

The attemptUpdate() subscriber watched for .updateAvailable state to
auto-confirm, but showUpdateFound used setStateAfterMinimumCheckDelay
which delays the transition by up to 2 seconds. During that window,
dismissUpdateInstallation (from a background probe race) could cancel
the pending transition, reverting state to idle without ever confirming.
The subscriber then tore down on the transient idle, silently abandoning
the update.

Fix: move auto-confirm to the Sparkle driver level via an
autoInstallOnNextUpdate flag. When set, showUpdateFound immediately
calls reply(.install) bypassing the delay entirely. The subscriber
is kept as a fallback but no longer tears down on transient idle
while the flag is active.

Closes #2166
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8de6a7a7-1944-4160-889a-3d189c5e240f

📥 Commits

Reviewing files that changed from the base of the PR and between 1cd842d and ad5e591.

📒 Files selected for processing (1)
  • Sources/Update/UpdateController.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/Update/UpdateController.swift

📝 Walkthrough

Walkthrough

The pull request refines the termination condition in UpdateController.swift's update monitoring subscription by excluding the .idle state from triggering early termination, ensuring monitoring continues while background checks or retry logic proceeds before stopping at only terminal failure states.

Changes

Cohort / File(s) Summary
Update Monitoring Logic
Sources/Update/UpdateController.swift
Refined subscription termination condition to exclude .idle state, ensuring monitoring persists during transient background operations while stopping only on terminal failure states.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~5 minutes

Possibly related PRs

Poem

🐰 The monitor hops with gentler grace,
No longer halting in idle space,
It waits through checks and retry's dance,
Till failures come to end the chance! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing the update monitoring from stopping prematurely during transient idle states, which was causing the auto-confirm logic to not fire.
Description check ✅ Passed The description includes a clear summary of the change and its rationale, plus testing notes. However, it is missing the Demo Video section and the Review Trigger checklist items required by the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2166-update-pill-no-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 25, 2026 10:18pm

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 25, 2026

Greptile Summary

This PR fixes a timing race where attemptUpdate() would trigger the UI pill refresh (showing the check-for-updates spinner) without actually completing the install, by moving the auto-confirm decision into UpdateDriver at the Sparkle callback level rather than relying on a Combine subscriber race against a 2-second minimum-display delay.

Key changes:

  • Adds autoInstallOnNextUpdate: Bool flag on UpdateDriver; when set, showUpdateFound immediately calls reply(.install) and returns, bypassing setStateAfterMinimumCheckDelay entirely
  • Clears the flag on every terminal path: showUpdateNotFoundWithError, showUpdaterError, dismissUpdateInstallation, and stopAttemptUpdateMonitoring
  • Prevents the attemptInstallCancellable subscriber from tearing down prematurely on a transient .idle while the flag is still active
  • Retains the existing state.confirm() subscriber path as a fallback for the race where dismissUpdateInstallation fires while still in .checking (where it is ignored), clearing the flag before showUpdateFound fires
  • One style-level concern: autoInstallOnNextUpdate is accessed in SPUUserDriver callbacks before any runOnMain dispatch, while the same callbacks use runOnMain for all viewModel.state mutations — suggesting a threading assumption that could benefit from an explicit @MainActor annotation or inline comment

Confidence Score: 4/5

  • Safe to merge; the logic is sound and all terminal paths clear the flag — one non-blocking thread-safety annotation is worth considering as a follow-up.
  • The fast path (flag set → immediate reply) correctly prevents double-confirm since state never reaches .updateAvailable. The fallback path (flag cleared by a dismiss during .checking) is handled by the pre-existing subscriber. All failure paths clear the flag. The only open item is that autoInstallOnNextUpdate is a plain var Bool read/written in SPUUserDriver callbacks that defensively use runOnMain for everything else, leaving an implicit threading assumption undocumented — a P2 style concern, not a blocking bug.
  • No files require special attention; UpdateDriver.swift line 17 has the one P2 threading note worth reviewing.

Important Files Changed

Filename Overview
Sources/Update/UpdateDriver.swift Adds autoInstallOnNextUpdate flag that bypasses the minimum-check-display delay in showUpdateFound and clears the flag on all terminal paths (showUpdateNotFoundWithError, showUpdaterError, dismissUpdateInstallation). The flag is a plain var Bool without explicit thread-safety guarantees.
Sources/Update/UpdateController.swift Sets autoInstallOnNextUpdate = true at the start of attemptUpdate and false in stopAttemptUpdateMonitoring. Adds a guard that prevents the subscriber from tearing down on a transient .idle state while the flag is still set. Keeps the existing state.confirm() subscriber path as a fallback.

Sequence Diagram

sequenceDiagram
    participant C as UpdateController
    participant D as UpdateDriver
    participant S as Sparkle

    C->>C: attemptUpdate()
    C->>C: stopAttemptUpdateMonitoring()
    C->>D: autoInstallOnNextUpdate = true
    C->>C: subscribe to viewModel.$state
    C->>S: checkForUpdates()

    alt Fast path - flag still set when update found
        S-->>D: showUpdateFound(appcastItem, reply)
        D->>D: autoInstallOnNextUpdate = false
        D->>S: reply(.install) bypasses minimum-check delay
        S-->>D: showDownloadInitiated / extracting / ready
        D-->>C: subscriber sees progress states then idle, tears down
    else Race - dismissUpdateInstallation fires before showUpdateFound
        S-->>D: dismissUpdateInstallation()
        D->>D: autoInstallOnNextUpdate = false
        Note over D: state is .checking, dismiss is ignored
        S-->>D: showUpdateFound(appcastItem, reply)
        D->>D: setStateAfterMinimumCheckDelay(.updateAvailable)
        D-->>C: state -> .updateAvailable after delay
        C->>C: fallback subscriber calls state.confirm()
    else Failure path
        S-->>D: showUpdateNotFoundWithError or showUpdaterError
        D->>D: autoInstallOnNextUpdate = false
        D-->>C: subscriber tears down on terminal state
    end
Loading

Reviews (1): Last reviewed commit: "Fix update attempt refreshing pill witho..." | Re-trigger Greptile

Comment thread Sources/Update/UpdateDriver.swift Outdated
/// without waiting for the minimum-check-display delay. This prevents
/// the delayed `.updateAvailable` transition from being preempted by
/// a `dismissUpdateInstallation` call (e.g. from a background probe race).
var autoInstallOnNextUpdate: Bool = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Thread-safety of autoInstallOnNextUpdate not formally guaranteed

The existing SPUUserDriver implementation wraps all viewModel.state mutations in runOnMain, which implies Sparkle may invoke driver callbacks from a background thread. autoInstallOnNextUpdate is now read and written in those same callbacks (showUpdateFound, showUpdateNotFoundWithError, showUpdaterError, dismissUpdateInstallation) without any synchronization — but it is also written from UpdateController on the main thread.

If Sparkle ever calls showUpdateFound or dismissUpdateInstallation off the main thread at the same moment UpdateController sets the flag, there is a formal data race on the Bool. In practice this is likely benign on current Apple hardware (word-aligned Bool reads/writes are atomic), but Swift's memory model does not guarantee this.

Consider adding @MainActor to UpdateDriver or at least to the affected methods, or documenting the threading assumption explicitly so future changes don't inadvertently cross threads. Alternatively, since setStateAfterMinimumCheckDelay already dispatches to main via runOnMain, the autoInstallOnNextUpdate read/write in showUpdateFound could be folded into the same runOnMain block used by the rest of the method body.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Update/UpdateDriver.swift`:
- Around line 53-58: When taking the immediate-install branch
(autoInstallOnNextUpdate), ensure you cancel the pending "checking" timeout
established by setStateAfterMinimumCheckDelay(...) before calling
reply(.install); i.e., clear/invalidate the timer or invoke the existing
cancel/clear function for the check timeout so the delayed timeout cannot later
flip state to .notFound while you proceed with reply(.install).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0a8e9870-1a6f-4bdd-8a9b-d35b98868f56

📥 Commits

Reviewing files that changed from the base of the PR and between 99ca3c9 and 1cd842d.

📒 Files selected for processing (2)
  • Sources/Update/UpdateController.swift
  • Sources/Update/UpdateDriver.swift

Comment thread Sources/Update/UpdateDriver.swift Outdated
Comment on lines +53 to +58
if autoInstallOnNextUpdate {
autoInstallOnNextUpdate = false
UpdateLogStore.shared.append("auto-installing update (attemptUpdate)")
reply(.install)
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cancel pending check timeout when taking the immediate-install branch.

On Line 53, this path bypasses setStateAfterMinimumCheckDelay(...), so timeout/check-transition bookkeeping can stay armed. If state remains .checking long enough, the timeout path can still force .notFound unexpectedly.

Proposed fix
         if autoInstallOnNextUpdate {
             autoInstallOnNextUpdate = false
+            pendingCheckTransition?.cancel()
+            pendingCheckTransition = nil
+            checkTimeoutWorkItem?.cancel()
+            checkTimeoutWorkItem = nil
+            lastCheckStart = nil
             UpdateLogStore.shared.append("auto-installing update (attemptUpdate)")
             reply(.install)
             return
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if autoInstallOnNextUpdate {
autoInstallOnNextUpdate = false
UpdateLogStore.shared.append("auto-installing update (attemptUpdate)")
reply(.install)
return
}
if autoInstallOnNextUpdate {
autoInstallOnNextUpdate = false
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = nil
UpdateLogStore.shared.append("auto-installing update (attemptUpdate)")
reply(.install)
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Update/UpdateDriver.swift` around lines 53 - 58, When taking the
immediate-install branch (autoInstallOnNextUpdate), ensure you cancel the
pending "checking" timeout established by setStateAfterMinimumCheckDelay(...)
before calling reply(.install); i.e., clear/invalidate the timer or invoke the
existing cancel/clear function for the check timeout so the delayed timeout
cannot later flip state to .notFound while you proceed with reply(.install).

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Update/UpdateDriver.swift">

<violation number="1" location="Sources/Update/UpdateDriver.swift:54">
P1: Avoid clearing `autoInstallOnNextUpdate` when dismiss is ignored in `.checking`; this can drop the auto-install intent before `showUpdateFound` fires.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/Update/UpdateDriver.swift Outdated
The attemptUpdate() subscriber tore down monitoring whenever it saw
.idle after observing progress. During check startup (retry loop,
background probe race), state can transiently return to .idle before
Sparkle's interactive check begins. The subscriber interpreted this
as "check completed" and stopped monitoring, so the auto-confirm
for .updateAvailable never fired.

Fix: add !state.isIdle to the teardown guard so monitoring only
stops on terminal failures (.notFound, .error), not transient idle.

Closes #2166
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad5e591178

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Only stop on terminal failure states (.notFound, .error).
// Don't stop on .idle — the check may still be starting up
// (e.g. retry loop, background probe finishing).
guard self.didObserveAttemptUpdateProgress, !state.isInstallable, !state.isIdle else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop attempt monitor when state settles back to idle

This new guard excludes .idle from terminal states, so attemptInstallCancellable is never cleared when an attempted install path returns to idle (for example after cancel/restart-later). In that case the subscriber keeps running indefinitely and will still auto-confirm() any future .updateAvailable, which can make a later normal update check install automatically without fresh user intent. Please keep the transient-idle protection for startup races, but still tear down monitoring once an attempt has progressed and then comes back to idle.

Useful? React with 👍 / 👎.

@lawrencecchen lawrencecchen merged commit b42f64f into main Mar 25, 2026
16 checks passed
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…w-ai#2168)

* Fix update attempt refreshing pill without actually updating

The attemptUpdate() subscriber watched for .updateAvailable state to
auto-confirm, but showUpdateFound used setStateAfterMinimumCheckDelay
which delays the transition by up to 2 seconds. During that window,
dismissUpdateInstallation (from a background probe race) could cancel
the pending transition, reverting state to idle without ever confirming.
The subscriber then tore down on the transient idle, silently abandoning
the update.

Fix: move auto-confirm to the Sparkle driver level via an
autoInstallOnNextUpdate flag. When set, showUpdateFound immediately
calls reply(.install) bypassing the delay entirely. The subscriber
is kept as a fallback but no longer tears down on transient idle
while the flag is active.

Closes manaflow-ai#2166

* Revert "Fix update attempt refreshing pill without actually updating"

This reverts commit 8ff45b55ce3c3b10db998c38eae6de2141f79284.

* Fix update attempt refreshing pill without actually updating

The attemptUpdate() subscriber tore down monitoring whenever it saw
.idle after observing progress. During check startup (retry loop,
background probe race), state can transiently return to .idle before
Sparkle's interactive check begins. The subscriber interpreted this
as "check completed" and stopped monitoring, so the auto-confirm
for .updateAvailable never fired.

Fix: add !state.isIdle to the teardown guard so monitoring only
stops on terminal failures (.notFound, .error), not transient idle.

Closes manaflow-ai#2166

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant